Skip to content

Switch HasMethodType/HasPropertyType from ObjectTypeTrait to MaybeObjectTypeTrait#5605

Merged
VincentLanglet merged 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7ulxx4w
May 11, 2026
Merged

Switch HasMethodType/HasPropertyType from ObjectTypeTrait to MaybeObjectTypeTrait#5605
VincentLanglet merged 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7ulxx4w

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

@phpstan-bot phpstan-bot commented May 6, 2026

Summary

  • Switched HasMethodType and HasPropertyType from ObjectTypeTrait to MaybeObjectTypeTrait, so string/scalar/object type queries return Maybe instead of definitive answers — these accessory types can appear in intersections with both objects and class-strings
  • Fixed isSuperTypeOf() in both types to delegate to CompoundType::isSubTypeOf(), making HasMethodType::isSuperTypeOf(MixedType) correctly return Maybe instead of Yes
  • Extracted MaybeStringTypeTrait to deduplicate string-related Maybe methods across HasMethodType, HasPropertyType, HasOffsetType, and HasOffsetValueType

Root cause

HasMethodType and HasPropertyType used ObjectTypeTrait, which made isString(), isScalar(), etc. return No. This caused incorrect results when these accessory types appeared in intersections with string-like types (e.g. class-string & literal-string & hasMethod(foo)). Additionally, isSuperTypeOf() did not delegate to CompoundType::isSubTypeOf(), so HasMethodType::isSuperTypeOf(MixedType) returned Yes (because MixedType::hasMethod() returns Yes), leading to false positive "will always evaluate to true" reports for method_exists() on object & T template types.

Test plan

  • Regression test for method_exists() on object & T (bug-8217.php)
  • Test cases for method_exists/property_exists with mixed and object types
  • Unit tests for HasMethodType::isSuperTypeOf(MixedType) = Maybe
  • Unit tests for HasPropertyType::isSuperTypeOf(MixedType) = Maybe
  • make tests — all 11,997 tests pass
  • make phpstan — no errors

Closes phpstan/phpstan#8217

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the issue seems related to TemplateMixedType, why it doesn't currently occur for MixedType ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's the answer to the reviewer's question:

Why doesn't MixedType trigger the same false positive?

The false positive requires two conditions to align in the general type specifier logic:

  1. ObjectWithoutClassType::isSuperTypeOf($argumentType) returns Yes (the type is definitely an object)
  2. HasMethodType::isSuperTypeOf($argumentType) returns Yes (the type definitely has the method)

For plain mixed: condition 1 fails — ObjectWithoutClassType::isSuperTypeOf(MixedType) returns Maybe (mixed isn't necessarily an object). So the overall result is Maybe, no false positive.

For plain object: condition 2 fails — HasMethodType::isSuperTypeOf(ObjectWithoutClassType) calls ObjectWithoutClassType::hasMethod() which returns Maybe (via ObjectTypeTrait). So the overall result is Maybe, no false positive.

For object&T (the bug case): both conditions are Yes — the type is already known to be an object (condition 1), AND IntersectionType::hasMethod() returns Yes because TemplateMixedType inherits MixedType::hasMethod() which returns Yes, dominating the intersection (condition 2). This makes the general logic conclude the check "will always evaluate to true."

I added test cases for the mixed and object scenarios to the test data file to demonstrate they work correctly.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should/could the fix be done in the general specifier ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The fix now addresses the reviewer's suggestion: instead of working around the issue in ImpossibleCheckTypeHelper, the fix is at the type system level by overriding hasMethod() on TemplateMixedType to return Maybe.

Why this is the right fix: MixedType::hasMethod() returns Yes because mixed encompasses all types. But TemplateMixedType represents a specific-but-unknown type T — we can't know whether that specific type has a given method. Returning Maybe makes the general type specifier logic in ImpossibleCheckTypeHelper work correctly without needing a special case: HasMethodType::isSuperTypeOf() gets Maybe from the template type, so the intersection object & T no longer claims to definitely have every method.

All 11,995 tests pass and make phpstan reports no errors.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List other possible fixes cause this one is not ok.

MixedType:: and TemplateMixedType methods shouldn't have different result.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. The fix now addresses the reviewer's concern:

What changed: Reverted the hasMethod() override on TemplateMixedType so it inherits MixedType::hasMethod() (both return Yes). Instead, the fix is in ImpossibleCheckTypeHelper::findSpecifiedType() — at the end of the method_exists specific handling block, if the object type contains template or late-resolvable types, return null (uncertain) rather than falling through to the general type specifier logic.

Why the general logic gives the wrong answer for object & T: IntersectionType::hasMethod() uses lazyMaxMin, which short-circuits on Yes. Since TemplateMixedType inherits MixedType::hasMethod() returning Yes, it dominates the intersection — making HasMethodType::isSuperTypeOf() conclude the method definitely exists.

Alternatives considered:

  1. HasMethodType::isSuperTypeOf(): Could check for template types, but would incorrectly return Maybe for bounded templates (T of SomeClass) where the method genuinely exists on the bound
  2. IntersectionType::hasMethod(): Changing from lazyMaxMin to lazyMin would be a large behavioral change affecting all intersections
  3. General result processing: Capping results at Maybe for template types — equivalent but less targeted

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the fix should be inside HasMethodType::isSuperTypeOf() instead.

For instance HasMethodType::isSuperTypeOf(Mixed) should be Maybe only (this would be consistent with MixedType::isSubtypeOf(HasMethod) which is maybe)

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

I processed this review but have nothing to report.

@staabm
Copy link
Copy Markdown
Contributor

staabm commented May 7, 2026

why don't we have the same bug for property_exists?

https://phpstan.org/r/01359ac6-3f8e-4ea2-b6f3-82b1e2de8b3f

@VincentLanglet
Copy link
Copy Markdown
Contributor

Indeed, I'll ask the bot

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we have the same bug for property_exists?
https://phpstan.org/r/01359ac6-3f8e-4ea2-b6f3-82b1e2de8b3f

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's the answer to the reviewer's question:

Why property_exists doesn't have the same bug:

The false positive requires the general type specifier logic to conclude "always true." For property_exists, this doesn't happen because PropertyExistsTypeSpecifyingExtension bails out early at line 87-89: when it finds that the property reflection is non-native (!$propertyReflection->isNative()), it returns empty SpecifiedTypes. Since MixedType provides non-native properties, the extension returns nothing for the impossible check logic to analyze — so no false positive occurs.

For method_exists, there's no equivalent bailout — the MethodExistsTypeSpecifyingExtension always returns specified types, and the dedicated handling in ImpossibleCheckTypeHelper falls through to the general logic for object & T.

But the underlying type inconsistency was the same: HasPropertyType::isSuperTypeOf(MixedType) was returning Yes (because MixedType::hasInstanceProperty() returns Yes) when it should return Maybe (because MixedType::isSubTypeOf(HasPropertyType) returns Maybe). Applied the same CompoundType delegation fix from HasMethodType to HasPropertyType for consistency, with a unit test confirming HasPropertyType::isSuperTypeOf(MixedType) = Maybe. Added property_exists test cases to the regression test.

I also investigated HasOffsetType and HasOffsetValueType which have the same missing delegation, but applying the fix there causes side effects in type narrowing (extra hasOffset() types appearing in intersections) — those would need separate investigation.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use

if ($type instanceof CompoundType) {
			return $type->isSubTypeOf($this);
		}

to fix this issue and explains why it impacts class-string&literal-string.

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's the summary:

Changes made:

  1. Simplified HasMethodType::isSuperTypeOf() and HasPropertyType::isSuperTypeOf() — replaced the fallback-on-No pattern with the standard return $type->isSubTypeOf($this) that other AccessoryType classes use.

  2. Added return null guards in ImpossibleCheckTypeHelper to prevent the general type specifier logic from producing false "always false" reports on class-string types.

Why this impacts class-string&literal-string:

With the simplified delegation, HasMethodType::isSuperTypeOf(class-string&literal-string) returns No instead of the previous Maybe. This happens because IntersectionType::isSubTypeOf(HasMethodType) uses lazyMaxMin over the individual intersection members (ClassStringType, AccessoryLiteralStringType), and neither has methods — both return No from HasMethodType::isSuperTypeOf(). The previous code's fallback ($type->hasMethod() on the IntersectionType) would also return No, but wasn't reached when isSubTypeOf returned Maybe or Yes.

The dedicated method_exists handling in ImpossibleCheckTypeHelper already correctly handles generic class-string types (non-final class-string<S>) by not returning false. The fix adds explicit return null for both that case and plain class-string types, preventing fallthrough to the general logic that would now incorrectly report "always false."

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand your explanation

With the simplified delegation, HasMethodType::isSuperTypeOf(class-string&literal-string) returns No instead of the previous Maybe. This happens because IntersectionType::isSubTypeOf(HasMethodType) uses lazyMaxMin over the individual intersection members (ClassStringType, AccessoryLiteralStringType), and neither has methods — both return No from HasMethodType::isSuperTypeOf().

Because both ClassStringType::hasMethod and AccessoryLiteralStringType::hasMethod are supposed to return MAYBE

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's the explanation for the reviewer:

Why the class-string guard was needed (and why it's no longer needed):

The reviewer is correct that both ClassStringType::hasMethod() and AccessoryLiteralStringType::hasMethod() return Maybe. The previous explanation was wrong about them returning No from HasMethodType::isSuperTypeOf().

The actual issue: AccessoryLiteralStringType implements CompoundType, so when HasMethodType::isSuperTypeOf(AccessoryLiteralStringType) was called, the unconditional return $type->isSubTypeOf($this) delegated to AccessoryLiteralStringType::isSubTypeOf(HasMethodType). That method checks HasMethodType::isLiteralString() which returns No (via ObjectTypeTrait), making the whole call return No — bypassing hasMethod() entirely. Then in IntersectionType::isSubTypeOf(HasMethodType), lazyMaxMin(Maybe, No) produced No because $hasNo was set.

The fix: When the CompoundType delegation returns No, fall back to the hasMethod()/hasProperty() check. Now HasMethodType::isSuperTypeOf(AccessoryLiteralStringType) correctly returns Maybe (from hasMethod()), making HasMethodType::isSuperTypeOf(class-string&literal-string) return Maybe as expected. This eliminates the need for the class-string guard in ImpossibleCheckTypeHelper.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at code like

$objectType = $scope->getType($args[0]->value);
		if ($objectType->isString()->yes()) {
			if ($objectType->isClassString()->yes()) {
				return $this->typeSpecifier->create(
					$args[0]->value,
					new IntersectionType([
						$objectType,
						new HasMethodType($methodNameType->getValue()),
					]),
					$context,
					$scope,
				);
			}

			return new SpecifiedTypes([], []);
		}

I feel like the issue is that HasMethod::isString (and similar method) return No because the ObjectTrait is used while it should return Maybe WDYT ?

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's the summary of what was done:

The reviewer was right: HasMethodType::isString() (and similar methods) returning No via ObjectTypeTrait was the root cause. When AccessoryLiteralStringType::isSubTypeOf(HasMethodType) delegated to check HasMethodType::isLiteralString(), it got No and incorrectly concluded the types were incompatible — which then cascaded through IntersectionType::isSubTypeOf() to break class-string & literal-string intersections.

What changed:

  1. HasMethodType and HasPropertyType: Override 9 string/scalar methods (isString(), isNumericString(), isNonEmptyString(), isNonFalsyString(), isLiteralString(), isLowercaseString(), isUppercaseString(), isClassString(), isScalar()) to return Maybe instead of No. This makes them agnostic about whether the underlying type is a string or object, which is correct since method_exists()/property_exists() work on both objects and class-strings.

  2. Kept ObjectTypeTrait for object-related behavior (isObject() → Yes, canCallMethods() → Yes, etc.) because changing those causes regressions in callable checking after method_exists() narrowing.

  3. Removed the ImpossibleCheckTypeHelper workaround (the return null addition) — no longer needed since the type system now gives correct answers through CompoundType delegation.

Comment thread src/Type/Accessory/HasMethodType.php
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HasMethodType and HasPropertyType should rely on MaybeObjectTypeTrait rather than ObjectTypeTrait (and maybe override some property).

Also, a MaybeStringTypeTrait might be useful to deduplicate some methods

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

All 11,997 tests pass and make phpstan reports no errors after the rebase.

Here's a summary of the changes:

Commit 1: Extract MaybeStringTypeTrait from HasOffsetType/HasOffsetValueType

  • Created src/Type/Traits/MaybeStringTypeTrait.php with the 9 string/scalar methods (isString, isNumericString, isNonEmptyString, isNonFalsyString, isLiteralString, isLowercaseString, isUppercaseString, isClassString, isScalar) that return Maybe
  • Applied the trait to HasOffsetType and HasOffsetValueType, removing the duplicate inline methods

Commit 2: Switch HasMethodType/HasPropertyType from ObjectTypeTrait to MaybeObjectTypeTrait

  • Replaced use ObjectTypeTrait with use MaybeObjectTypeTrait plus the sub-traits (MaybeCallableTypeTrait, MaybeIterableTypeTrait, MaybeOffsetAccessibleTypeTrait, NonArrayTypeTrait, TruthyBooleanTypeTrait) and MaybeStringTypeTrait
  • Added explicit methods for scalar checks, conversions, and other behavior that ObjectTypeTrait provided but MaybeObjectTypeTrait doesn't (matching the pattern used by HasOffsetType)
  • Preserved getObjectTypeOrClassStringObjectType() returning $this and toCoercedArgumentType() coercion logic, which are needed for callable array resolution and string coercion

Comment thread src/Type/Accessory/HasMethodType.php Outdated
return TrinaryLogic::createMaybe();
}

public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getCallableParametersAcceptors is already in MaybeCallableTypeTrait

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Here's a summary of the changes addressing each review comment:

  1. Removed redundant getCallableParametersAcceptors() from HasMethodType — already provided by MaybeCallableTypeTrait (and removed unused TrivialParametersAcceptor imports from both files)

  2. Moved getConstantStrings() into MaybeStringTypeTrait — removed the duplicate getConstantStrings() returning [] from HasMethodType, HasPropertyType, HasOffsetType, and HasOffsetValueType

  3. getClassStringObjectType() returns $this instead of ErrorType — this preserves method/property info when resolving class-string intersections. Returning ObjectWithoutClassType (as initially suggested) loses the method info, which breaks callable detection after method_exists() checks (e.g., new $factory() after method_exists($factory, '__invoke'))

  4. getObjectTypeOrClassStringObjectType() keeps returning $this — same rationale. NewHandler uses this method to resolve new $variable() types, and returning ObjectWithoutClassType loses the HasMethodType info needed for callable checks

Comment thread src/Type/Accessory/HasMethodType.php Outdated
return [];
}

public function getConstantStrings(): array
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be in MaybeStringTrait ? And I dunno if some other method should be added in the MaybeStringTypeTrait.

Comment thread src/Type/Accessory/HasMethodType.php Outdated

public function getClassStringObjectType(): Type
{
return new ErrorType();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be ObjectWithoutClassType

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest a better title and description for the PR now

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The token doesn't have write access to the upstream repo. Since there are no code changes to make (this is just a title/description update request), I'll report the suggested title and description for the user to apply.

The bot token doesn't have permission to edit the PR on phpstan/phpstan-src. Here's the suggested update:

New title:

Switch HasMethodType/HasPropertyType from ObjectTypeTrait to MaybeObjectTypeTrait

New description:


Summary

  • Switched HasMethodType and HasPropertyType from ObjectTypeTrait to MaybeObjectTypeTrait, so string/scalar/object type queries return Maybe instead of definitive answers — these accessory types can appear in intersections with both objects and class-strings
  • Fixed isSuperTypeOf() in both types to delegate to CompoundType::isSubTypeOf(), making HasMethodType::isSuperTypeOf(MixedType) correctly return Maybe instead of Yes
  • Extracted MaybeStringTypeTrait to deduplicate string-related Maybe methods across HasMethodType, HasPropertyType, HasOffsetType, and HasOffsetValueType

Root cause

HasMethodType and HasPropertyType used ObjectTypeTrait, which made isString(), isScalar(), etc. return No. This caused incorrect results when these accessory types appeared in intersections with string-like types (e.g. class-string & literal-string & hasMethod(foo)). Additionally, isSuperTypeOf() did not delegate to CompoundType::isSubTypeOf(), so HasMethodType::isSuperTypeOf(MixedType) returned Yes (because MixedType::hasMethod() returns Yes), leading to false positive "will always evaluate to true" reports for method_exists() on object & T template types.

Test plan

  • Regression test for method_exists() on object & T (bug-8217.php)
  • Test cases for method_exists/property_exists with mixed and object types
  • Unit tests for HasMethodType::isSuperTypeOf(MixedType) = Maybe
  • Unit tests for HasPropertyType::isSuperTypeOf(MixedType) = Maybe
  • make tests — all 11,997 tests pass
  • make phpstan — no errors

Closes phpstan/phpstan#8217


The token lacks write access to phpstan/phpstan-src, so this needs to be applied manually or with a token that has repo write permissions.

@VincentLanglet VincentLanglet changed the title Do not let method_exists() check fall through to general logic when object type contains template types Switch HasMethodType/HasPropertyType from ObjectTypeTrait to MaybeObjectTypeTrait May 7, 2026
@VincentLanglet VincentLanglet self-assigned this May 7, 2026
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-7ulxx4w branch from e729d86 to efa69dc Compare May 10, 2026 09:28
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix

FILE: src/Type/Accessory/HasPropertyType.php
--------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------
 8 | ERROR | [x] Type PHPStan\Reflection\ClassMemberAccessAnswerer is not used
   |       |     in this file.
   |       |     (SlevomatCodingStandard.Namespaces.UnusedUses.UnusedUse)
--------------------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
--------------------------------------------------------------------------------

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Background tests also completed successfully. Everything's green — the fix is pushed.

@VincentLanglet VincentLanglet requested a review from staabm May 10, 2026 11:08
@staabm staabm force-pushed the create-pull-request/patch-7ulxx4w branch from 6785bbc to f0812c1 Compare May 11, 2026 12:36
Comment thread src/Type/Accessory/HasMethodType.php
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-7ulxx4w branch from f0812c1 to ceb425f Compare May 11, 2026 14:16
@VincentLanglet VincentLanglet requested a review from staabm May 11, 2026 15:37
@VincentLanglet VincentLanglet merged commit 37909c9 into phpstan:2.1.x May 11, 2026
1379 of 1555 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-7ulxx4w branch May 11, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants